Skip to content

fix(data-objectstack): classify any coded 400 as a query rejection - #8075

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-7755-analytics-coded-400-rejected
Sep 6, 2026
Merged

fix(data-objectstack): classify any coded 400 as a query rejection#8075
os-justin merged 1 commit into
mainfrom
claude/issue-7755-analytics-coded-400-rejected

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #7755

What

classifyAnalyticsFailure (packages/data-objectstack/src/index.ts) only recognized 400 VALIDATION_FAILED (or a code-less 400) as a refusal of our own query body. A 400 carrying any OTHER code — service-analytics ships its own 400 INVALID_FILTER on a filter shape it refuses — matched none of the four code branches, fell through to unknown, and aggregate()'s catch has no unknown arm, so it silently degraded to aggregateViaFind: a re-read through find()'s $filter query-string contract, a different door that accepts array shapes the analytics POST body does not. A filter the analytics route refused could still render a plausible, wrong number with no sign the request had a defect.

Adjudication (binding, from the PM dispatch on #7755): status === 400 implies rejected regardless of whether a code is present. The four existing code branches (NOT_IMPLEMENTED/ROUTE_NOT_FOUNDnot-installed, VALIDATION_FAILEDrejected, UNAUTHENTICATED, CUBE_NOT_FOUND) are neither removed nor reordered — this is a floor added beneath them. An unmatched non-400 coded error keeps degrading, unchanged; that question was explicitly ruled out of this card's scope.

Change

  • classifyAnalyticsFailure: added if (status === 400) return { kind: 'rejected', ... } immediately after the CUBE_NOT_FOUND branch and before the code-less residual, and removed the now-redundant/unreachable status === 400 check that lived inside the code === undefined gate (the new floor already covers that case). Extended the existing rejected-outcome doc comment in place (per the PM's suggested route) rather than adding a second explanation.
  • aggregate-capability.test.ts: extended the module doc table and added a pin — a coded 400 (INVALID_FILTER, the real code service-analytics ships for a refused filter shape, chosen over a made-up code) reaches the caller as AnalyticsQueryRejectedError (with serverCode preserved) and never triggers a /api/v1/data fallback call.

Mechanism assumptions — measured, not assumed

  • Reachability: LIVE, not latent. Confirmed find()'s fallback path (aggregateViaFindthis.find(resource, { $filter: params.filter })) is a genuinely different door than the analytics POST body — find() routes through client.data.find() → objectql's $filter query-string contract, while aggregate()'s analytics path posts through lowerAnalyticsFilterForWire into client.analytics.query(). These are different parsers with different accepted shapes (documented in the surrounding code re: element:number array-only shapes, objectstack#12039 / objectui#7752).
  • The triage comment on classifyAnalyticsFailure classifies a coded 400 other than VALIDATION_FAILED as unknown, so aggregate() answers a server refusal with client-side numbers #7755 (os-zhuang) independently verified, from the sibling objectstack repo tree, that /analytics/query relays both code and status verbatim (dispatcher-plugin.errorResponseBase, pinned in analytics-query-read-scope-withhold.test.ts) and that service-analytics does ship 400 INVALID_FILTER (spec/src/api/errors.zod.ts standard catalog, multiple pins across the client/core packages) — so this is live, not merely latent.

Gates (run from repo root per this repo's vitest-invocation discipline)

  • pnpm exec vitest run packages/data-objectstack/src/aggregate-capability.test.ts — 13/13 passed.
  • pnpm exec vitest run packages/data-objectstack/ — 56 test files, 749 tests, all passed.
  • pnpm --filter @object-ui/data-objectstack type-check — clean, 0 errors (after building the @object-ui/types / @object-ui/core dependency closure first).
  • node scripts/check-control-bytes.mjs — OK (6472 scanned, 85 skipped-binary).
  • node scripts/check-changeset-presence.mjs — OK, 1 changeset declared for the 2 changed source files.
  • node scripts/check-changeset-no-major.mjs — OK, no major bump declared.
  • check:published-dist / check:published-tsconfig-excludenot run locally (both rebuild the full workspace, ~30+ packages). This PR's diff touches no package.json publish-contract field and no tsconfig*.json for data-objectstack (verified: git diff origin/main -- packages/data-objectstack/package.json packages/data-objectstack/tsconfig*.json is empty) — both gates are structurally invariant to this diff. Deferred to CI.

Changeset

.changeset/analytics-coded-400-rejected.md@object-ui/data-objectstack: minor (matches the bump used by the closest precedent, analytics-failure-code-first.md for objectui#5721, which changed the same function's throw-vs-degrade behavior the same way).


🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

`classifyAnalyticsFailure` only recognized 400 `VALIDATION_FAILED` (or a
code-less 400) as a refusal of our query body. A 400 carrying any OTHER
code -- service-analytics ships its own 400 `INVALID_FILTER` on a filter
shape it refuses -- matched none of the branches and fell through to
`unknown`, which `aggregate()`'s catch has no arm for, so it silently
degraded to `aggregateViaFind`: a re-read through `find()`'s `$filter`
query-string contract, a different door that accepts array shapes the
analytics body does not. A filter the analytics route refused could still
render a plausible, wrong number with no sign the request had a defect.

Add a floor beneath the four existing code branches (objectui#5721):
`status === 400` now implies `rejected` regardless of whether a code is
present, placed after CUBE_NOT_FOUND and before the code-less residual so
none of the four established code branches are reordered or shadowed.
Whether an unmatched non-400 coded error should keep degrading is an
explicit non-goal here (ruled out of scope) -- it is unaffected.

Pinned with a coded-400 INVALID_FILTER case: reaches the caller as
AnalyticsQueryRejectedError and never reaches aggregateViaFind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3186.2 KB 3191.4 KB
Main entry chunk (gzip) 143.5 KB 350 KB
Entry file index-DHQAXEr_.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 497.06KB 113.79KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.43KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 47.29KB 13.18KB
plugin-charts (index.js) 70.43KB 19.71KB
plugin-chatbot (index.js) 193.53KB 46.05KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 211.51KB 43.01KB
plugin-detail (index.js) 247.59KB 63.48KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.56KB 56.63KB
plugin-kanban (index.js) 52.30KB 14.49KB
plugin-list (index.js) 113.24KB 27.66KB
plugin-map (index.js) 20.35KB 6.77KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 29.95KB 8.67KB
plugin-tree (index.js) 9.19KB 3.19KB
plugin-view (index.js) 84.33KB 20.75KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Landing — contract review PASSED, re-run green, standing down on one red that is not this PR's

By the dispatching seat (session_01YBWFb5YgMU5dw8p2VKj16S, os-justin), 15:2xZ.

needs:contract-review → PASS

Reviewed at CONTRACT_REVIEW_TIER against origin/main (295804a63). Clause-② direction: narrows only. classifyAnalyticsFailure is exported, but its return union's kind gains no arm — 'rejected' already existed. The set of server answers aggregate() will answer with a value shrinks by exactly the coded-400 set; nothing accepts more. No schema arm, declared key, exported type or registration input moves.

All four premise checks hold, and the non-shadowing claim was verified rather than taken on the PR's word: branches ①–④ sit at :1172/1177/1180/1183 and each passes { code } — the literal object, status not in it — to errorCodeIs / errorCodeIsAnyOf, so none of them can be shadowed by the new floor. 401 stays inside the code === undefined gate; ANALYTICS_ABSENT_STATUSES (:1057, [404, 501]) is untouched, so objectui#5721 is not reversed.

⭐ The pin was checked for vacuity and is not vacuous: pre-fix a coded 400 reaches unknown, aggregate()'s catch has no unknown arm, so aggregateViaFind resolves — meaning expect(err).toBeInstanceOf(Error) genuinely fails without the fix. And INVALID_FILTER is corroborated in-tree, not only from triage's cross-repo read: packages/core/src/utils/filter-converter.ts:48 declares readonly code = 'INVALID_FILTER' and packages/data-objectstack/README.md:461 documents it as a 400 on this face. Changeset minor confirmed correct, matching the #5721 precedent on the same function.

The one re-run of this round was spent here, and it was justified

Build & E2E failed at 11 seconds, at the pnpm --version step — before install — on a corepack/undici parser assertion while downloading pnpm-10.31.0.tgz. That is the "died before any test body ran" case, the one class the standing rule permits re-running.

⭐ It was not called a flake on a hunch. Two controls fired first: Build Docs, a sibling job in the same workflow run, completed that identical step successfully; and PR #8077's Build & E2E passed green on the same infrastructure in the same window. ⇒ Transient download failure, ⛔ not an outage and ⛔ not this diff.

Re-run result: Build & E2E success at 15:11:04Z, and all four Test shards green. 32 of 32 check runs enumerated (returned count equals totalCount).

⛔ Standing down on Live E2E (informational) — it is NOT this PR's

Verified first-hand on main, ⛔ not inherited: run 33950494089 job 101264218858 (09-05) has step 7 Start ObjectStack backend (published packages) green in 76s; run 34017174769 job 101442890465 (09-06) has the same step failing after ~6 minutes, steps 8–14 all skipped. The break is new in that window and reproducible on main.

⚠️ And the trap that hid it: the 09-06 run reports conclusion: success at the RUN level while its job fails. ⛔ Never read a workflow run's conclusion as evidence in this repo.

No fix exists to port; the break is undiagnosed and carded as objectui#8084. ⛔ No re-run spent on it — a reproducible base-red is not a flake, and a second run would only re-confirm what two main runs already establish.

⇒ Stripping needs:contract-review, flipping ready, enqueuing.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants